Libigl Tutorials¶

Libigl is an open source C++ library for geometry processing research and development. The python bindings combine the rapid prototyping familiar to Matlab to Python programmers with the performance and versatility of C++. The tutorial is a self-contained, hands-on introduction to libigl in Python. Via interactive, step-by-step examples, we demonstrate how to accomplish common geometry processing tasks such as computation of differential quantities and operators, real-time deformation, parametrization, numerical optimization and remeshing. Each section of the lecture notes contains a simple Python example application.
Chapter 0¶
We introduce libigl with a series of self-contained examples. The purpose of each example is to showcase a feature of libigl while applying to a practical problem in geometry processing. In this chapter, we will present the basic concepts of libigl.
Libigl design principles¶
Before getting into the examples, we summarize the two main design principles in libigl:
-
No complex data types. We mostly use
numpyorscipymatrices and vectors. This greatly favors code reusability and interoperability and forces the function authors to expose all the parameters used by the algorithm. -
Function encapsulation. Every function is contained in a unique Python function.
Downloading Libigl¶
Libigl can be downloaded from Conda forge:
conda install -c conda-forge igl
All of libigl functionality depends only on numpy and scipy. For the visualization in this tutorial we use meshplot which can be easily installed from Conda:
conda install -c conda-forge meshplot
To start using libigl (with the plots) you just need to import it together with the numpy, scipy, and meshplot.
import igl import scipy as sp import numpy as np from meshplot import plot, subplot, interact import os # root_folder = os.getcwd() root_folder = os.path.join(os.getcwd(), "tutorial")
Mesh representation¶
Libigl uses numpy to encode vectors and matrices and scipy for sparse matrices.
A triangular mesh is encoded as a pair of matrices:
v: np.array f: np.array
v is a #N by 3 matrix which stores the coordinates of the vertices. Each
row stores the coordinate of a vertex, with its x, y and z coordinates in the first,
second and third column, respectively. The matrix f stores the triangle
connectivity: each line of f denotes a triangle whose 3 vertices are
represented as indices pointing to rows of f.

V = np.array([ [0., 0, 0], [1, 0, 0], [1, 1, 1], [2, 1, 0] ]) F = np.array([ [0, 1, 2], [1, 3, 2] ]) plot(V, F)
Note that the order of the vertex indices in f determines the orientation of
the triangles and it should thus be consistent for the entire surface.
This simple representation has many advantages:
- It is memory efficient and cache friendly
- The use of indices instead of pointers greatly simplifies debugging
- The data can be trivially copied and serialized
Libigl provides input and output functions to read and write many common mesh formats. The IO functions are igl.read_* and igl.write_*.
Reading a mesh from a file requires a single libigl function call:
## Load a mesh in OFF format v, f = igl.read_triangle_mesh(os.path.join(root_folder, "data", "bunny.off")) ## Print the vertices and faces matrices print("Vertices: ", len(v)) print("Faces: ", len(f))
Vertices: 3485 Faces: 6966
The function reads the mesh bumpy.off and returns the v and f matrices.
Similarly, a mesh can be written to an OBJ file using:
## Save the mesh in OBJ format ret = igl.write_triangle_mesh(os.path.join(root_folder, "data", "bunny_out.obj"), v, f)
Chapter 1: Discrete Geometric Quantities and Operators¶
This chapter illustrates a few discrete quantities that libigl can compute on a mesh and the libigl functions that construct popular discrete differential geometry operators. It also provides an introduction to basic drawing and coloring routines of our viewer.
Gaussian curvature¶
Gaussian curvature on a continuous surface is defined as the product of the principal curvatures:
k_G = k_1 k_2.
As an intrinsic measure, it depends on the metric and not the surface’s embedding.
Intuitively, Gaussian curvature tells how locally spherical or elliptic the surface is ( k_G>0 ), how locally saddle-shaped or hyperbolic the surface is ( k_G<0 ), or how locally cylindrical or parabolic ( k_G=0 ) the surface is.
In the discrete setting, one definition for a “discrete Gaussian curvature” on a triangle mesh is via a vertex’s angular deficit:
k_G(v_i) = 2π - \sum\limits_{j\in N(i)}θ_{ij},
where N(i) are the triangles incident on vertex i and θ_{ij} is the angle at vertex i in triangle j (Meyer, 2003).
Just like the continuous analog, our discrete Gaussian curvature reveals elliptic, hyperbolic and parabolic vertices on the domain.
Let’s compute Gaussian curvature and visualize it in pseudocolor. First, calculate the curvature with libigl and then plot it in pseudocolors.
v, f = igl.read_triangle_mesh(os.path.join(root_folder, "data", "bumpy.off")) k = igl.gaussian_curvature(v, f) plot(v, f, k)
Next, compute the massmatrix and divide the gaussian curvature values by area to get the integral average.
m = igl.massmatrix(v, f, igl.MASSMATRIX_TYPE_VORONOI) minv = sp.sparse.diags(1 / m.diagonal()) kn = minv.dot(k) plot(v, f, kn)
Curvature directions¶
The two principal curvatures (k_1,k_2) at a point on a surface measure how much the surface bends in different directions. The directions of maximum and minimum (signed) bending are called principal directions and are always orthogonal.
Mean curvature is defined as the average of principal curvatures:
H = \frac{1}{2}(k_1 + k_2).
One way to extract mean curvature is by examining the Laplace-Beltrami operator applied to the surface positions. The result is a so-called mean-curvature normal:
-\Delta \mathbf{x} = H \mathbf{n}.
It is easy to compute this on a discrete triangle mesh in libigl using the cotangent Laplace-Beltrami operator (Meyer, 2003).
l = igl.cotmatrix(v, f) m = igl.massmatrix(v, f, igl.MASSMATRIX_TYPE_VORONOI) minv = sp.sparse.diags(1 / m.diagonal()) hn = -minv.dot(l.dot(v)) h = np.linalg.norm(hn, axis=1) plot(v, f, h)
Combined with the angle defect definition of discrete Gaussian curvature, one can define principal curvatures and use least squares fitting to find directions (Meyer, 2003).
Alternatively, a robust method for determining principal curvatures is via quadric fitting (Panozzo, 2010). In the neighborhood around every vertex, a best-fit quadric is found and principal curvature values and directions are analytically computed on this quadric.
v1, v2, k1, k2 = igl.principal_curvature(v, f) h2 = 0.5 * (k1 + k2) p = plot(v, f, h2, shading={"wireframe": False}, return_plot=True) avg = igl.avg_edge_length(v, f) / 2.0 p.add_lines(v + v1 * avg, v - v1 * avg, shading={"line_color": "red"}) p.add_lines(v + v2 * avg, v - v2 * avg, shading={"line_color": "green"});
Gradient¶
Scalar functions on a surface can be discretized as a piecewise linear function with values defined at each mesh vertex:
f(\mathbf{x}) \approx \sum\limits_{i=1}^n \phi_i(\mathbf{x})\, f_i,
where \phi_i is a piecewise linear hat function defined by the mesh so that for each triangle \phi_i is the linear function which is one only at vertex i and zero at the other corners.

Thus gradients of such piecewise linear functions are simply sums of gradients of the hat functions:
\nabla f(\mathbf{x}) \approx \nabla \sum\limits_{i=1}^n \phi_i(\mathbf{x})\, f_i = \sum\limits_{i=1}^n \nabla \phi_i(\mathbf{x})\, f_i.
This reveals that the gradient is a linear function of the vector of f_i values. Because the \phi_i are linear in each triangle, their gradients are constant in each triangle. Thus our discrete gradient operator can be written as a matrix multiplication taking vertex values to triangle values:
\nabla f \approx \mathbf{G}\,\mathbf{f},
where \mathbf{f} is n\times 1 and \mathbf{G} is an md\times n sparse matrix. This matrix \mathbf{G} can be derived geometrically (Jacobson, 2013).
Libigl’s grad function computes \mathbf{G} for
triangle and tetrahedral meshes.
Let’s see how this works. First load a mesh and a corresponding surface function.
Next, compute the gradient operator g (#F*3 x #V) on the triangle mesh, apply it to the surface function and extract the magnitude.
v, f = igl.read_triangle_mesh(os.path.join(root_folder, "data", "cheburashka.off")) u = igl.read_dmat(os.path.join(root_folder, "data", "cheburashka-scalar.dmat")) g = igl.grad(v, f) gu = g.dot(u).reshape(f.shape, order="F") gu_mag = np.linalg.norm(gu, axis=1) p = plot(v, f, u, shading={"wireframe":False}, return_plot=True) max_size = igl.avg_edge_length(v, f) / np.mean(gu_mag) bc = igl.barycenter(v, f) bcn = bc + max_size * gu p.add_lines(bc, bcn, shading={"line_color": "black"});
Laplacian¶
The discrete Laplacian is an essential geometry processing tool. Many interpretations and flavors of the Laplace and Laplace-Beltrami operator exist.
In open Euclidean space, the Laplace operator is the usual divergence of gradient (or equivalently the Laplacian of a function is the trace of its Hessian):
\Delta f = \frac{\partial^2 f}{\partial x^2} + \frac{\partial^2 f}{\partial y^2} + \frac{\partial^2 f}{\partial z^2}.
The Laplace-Beltrami operator generalizes this to surfaces.
When considering piecewise-linear functions on a triangle mesh, a discrete Laplacian may be derived in a variety of ways. The most popular in geometry processing is the so-called “cotangent Laplacian” \mathbf{L}, arising simultaneously from FEM, DEC and applying divergence theorem to vertex one-rings. As a linear operator taking vertex values to vertex values, the Laplacian \mathbf{L} is a n\times n matrix with elements:
L_{ij} = \begin{cases}j \in N(i) &\cot \alpha_{ij} + \cot \beta_{ij},\\ j \notin N(i) & 0,\\ i = j & -\sum\limits_{k\neq i} L_{ik}, \end{cases}
where N(i) are the vertices adjacent to (neighboring) vertex i, and \alpha_{ij},\beta_{ij} are the angles opposite to edge {ij}.
Libigl implements discrete “cotangent Laplacians” for triangles meshes and tetrahedral meshes, building both with fast geometric rules rather than “by the book” FEM construction which involves many (small) matrix inversions (Sharf, 2007).
First, load a triangle mesh and then calculate the Laplace-Beltrami operator, visualize the normals as pseudocolors.
from scipy.sparse.linalg import spsolve v, f = igl.read_triangle_mesh(os.path.join(root_folder, "data", "cow.off")) l = igl.cotmatrix(v, f) n = igl.per_vertex_normals(v, f)*0.5+0.5 c = np.linalg.norm(n, axis=1) p = plot(v, f, c, shading={"wireframe": False}, return_plot=True) vs = [v] cs = [c] for i in range(10): m = igl.massmatrix(v, f, igl.MASSMATRIX_TYPE_BARYCENTRIC) s = (m - 0.001 * l) b = m.dot(v) v = spsolve(s, m.dot(v)) n = igl.per_vertex_normals(v, f)*0.5+0.5 c = np.linalg.norm(n, axis=1) vs.append(v) cs.append(c) @interact(level=(0, 9)) def mcf(level=0): p.update_object(vertices=vs[level], colors=cs[level])
interactive(children=(IntSlider(value=0, description='level', max=9), Output()), _dom_classes=('widget-interac…
The operator applied to mesh vertex positions amounts to smoothing by flowing the surface along the mean curvature normal direction. Note that this is equivalent to minimizing surface area. The following example computes conformalized mean curvature flow using the cotangent Laplacian (Kazhdan, 2012)
Mass matrix¶
The mass matrix \mathbf{M} is another n \times n matrix which takes vertex values to vertex values. From an FEM point of view, it is a discretization of the inner-product: it accounts for the area around each vertex. Consequently, \mathbf{M} is often a diagonal matrix, such that M_{ii} is the barycentric or voronoi area around vertex i in the mesh (Meyer, 2003). The inverse of this matrix is also very useful as it transforms integrated quantities into point-wise quantities, e.g.:
\Delta f \approx \mathbf{M}^{-1} \mathbf{L} \mathbf{f}.
In general, when encountering squared quantities integrated over the surface, the mass matrix will be used as the discretization of the inner product when sampling function values at vertices:
\int_S x\, y\ dA \approx \mathbf{x}^T\mathbf{M}\,\mathbf{y}.
An alternative mass matrix \mathbf{T} is a md \times md matrix which takes triangle vector values to triangle vector values. This matrix represents an inner-product accounting for the area associated with each triangle (i.e. the triangles true area).
Alternative construction of Laplacian¶
An alternative construction of the discrete cotangent Laplacian is by “squaring” the discrete gradient operator. This may be derived by applying Green’s identity (ignoring boundary conditions for the moment):
\int_S \|\nabla f\|^2 dA = \int_S f \Delta f dA
Or in matrix form which is immediately translatable to code:
\mathbf{f}^T \mathbf{G}^T \mathbf{T} \mathbf{G} \mathbf{f} = \mathbf{f}^T \mathbf{M} \mathbf{M}^{-1} \mathbf{L} \mathbf{f} = \mathbf{f}^T \mathbf{L} \mathbf{f}.
So we have that \mathbf{L} = \mathbf{G}^T \mathbf{T} \mathbf{G}. This also hints that we may consider \mathbf{G}^T as a discrete divergence operator, since the Laplacian is the divergence of the gradient. Naturally, \mathbf{G}^T is a n \times md sparse matrix which takes vector values stored at triangle faces to scalar divergence values at vertices.
v, f = igl.read_triangle_mesh(os.path.join(root_folder, "data", "cow.off")) l = igl.cotmatrix(v, f) g = igl.grad(v, f) d_area = igl.doublearea(v, f) t = sp.sparse.diags(np.hstack([d_area, d_area, d_area]) * 0.5) k = -g.T.dot(t).dot(g) print("|k-l|: %s"%sp.sparse.linalg.norm(k-l))
|k-l|: 6.654117928693559e-13
Exact Discrete Geodesic Distances¶
The discrete geodesic distance between two points is the length of the shortest path between then restricted to the surface. For triangle meshes, such a path is made of a set of segments which can be either edges of the mesh or crossing a triangle.
Libigl includes a wrapper for the exact geodesic algorithm (Mitchell, 1987) developed by Danil Kirsanov (https://code.google.com/archive/p/geodesic/), exposing it through an Eigen-based API. The function
d = igl.exact_geodesic(v, f, vs, fs, vt, ft)
v, f = igl.read_triangle_mesh(os.path.join(root_folder, "data", "armadillo.obj")) ## Select a vertex from which the distances should be calculated vs = np.array([0]) ##All vertices are the targets vt = np.arange(v.shape[0]) d = igl.exact_geodesic(v, f, vs, vt)#, fs, ft) strip_size = 0.1 ##The function should be 1 on each integer coordinate c = np.abs(np.sin((d / strip_size * np.pi))) plot(v, f, c, shading={"wireframe": False})
Chapter 2: Matrices and Linear Algebra¶
Laplace equation¶
A common linear system in geometry processing is the Laplace equation:
∆z = 0
subject to some boundary conditions, for example Dirichlet boundary conditions (fixed value):
\left.z\right|_{\partial{S}} = z_{bc}
In the discrete setting, the linear system can be written as:
\mathbf{L} \mathbf{z} = \mathbf{0}
where \mathbf{L} is the n \times n discrete Laplacian and \mathbf{z} is a vector of per-vertex values. Most of \mathbf{z} correspond to interior vertices and are unknown, but some of \mathbf{z} represent values at boundary vertices. Their values are known so we may move their corresponding terms to the right-hand side.
Conceptually, this is very easy if we have sorted \mathbf{z} so that interior vertices come first and then boundary vertices:
The bottom block of equations is no longer meaningful so we’ll only consider the top block:
We can move the known values to the right-hand side:
Finally we can solve this equation for the unknown values at interior vertices \mathbf{z}_{in}.
However, our vertices will often not be sorted in this way. One option would be to sort V,
then proceed as above and then unsort the solution Z to match V. However,
this solution is not very general.
With array slicing no explicit sort is needed. Instead we can slice-out
submatrix blocks (\mathbf{L}_{in,in}, \mathbf{L}_{in,b}, etc.) and follow
the linear algebra above directly. Then we can slice the solution into the
rows of Z corresponding to the interior vertices.
from scipy.sparse.linalg import spsolve v, f = igl.read_triangle_mesh(os.path.join(root_folder, "data", "camelhead.off")) ## Find boundary vertices e = igl.boundary_facets(f) v_b = np.unique(e) ## List of all vertex indices v_all = np.arange(v.shape[0]) ## List of interior indices v_in = np.setdiff1d(v_all, v_b) ## Construct and slice up Laplacian l = igl.cotmatrix(v, f) l_ii = l[v_in, :] l_ii = l_ii[:, v_in] l_ib = l[v_in, :] l_ib = l_ib[:, v_b] ## Dirichlet boundary conditions from z-coordinate z = v[:, 2] bc = z[v_b] ## Solve PDE z_in = spsolve(-l_ii, l_ib.dot(bc)) plot(v, f, z)
Quadratic energy minimization¶
The same Laplace equation may be equivalently derived by minimizing Dirichlet energy subject to the same boundary conditions:
\mathop{\text{minimize }}_z \frac{1}{2}\int\limits_S \|\nabla z\|^2 dA
On our discrete mesh, recall that this becomes
\mathop{\text{minimize }}_\mathbf{z} \frac{1}{2}\mathbf{z}^T \mathbf{G}^T \mathbf{D} \mathbf{G} \mathbf{z} \rightarrow \mathop{\text{minimize }}_\mathbf{z} \mathbf{z}^T \mathbf{L} \mathbf{z}
The general problem of minimizing some energy over a mesh subject to fixed value boundary conditions is so wide spread that libigl has a dedicated api for solving such systems.
Let us consider a general quadratic minimization problem subject to different common constraints:
subject to
where
- \mathbf{Q} is a (usually sparse) n \times n positive semi-definite matrix of quadratic coefficients (Hessian),
- \mathbf{B} is a n \times 1 vector of linear coefficients,
- \mathbf{z}_b is a |b| \times 1 portion of \mathbf{z} corresponding to boundary or fixed vertices,
- \mathbf{z}_{bc} is a |b| \times 1 vector of known values corresponding to \mathbf{z}_b,
- \mathbf{A}_{eq} is a (usually sparse) m \times n matrix of linear equality constraint coefficients (one row per constraint), and
- \mathbf{B}_{eq} is a m \times 1 vector of linear equality constraint right-hand side values.
This specification is overly general as we could write \mathbf{z}_b =
\mathbf{z}_{bc} as rows of \mathbf{A}_{eq} \mathbf{z} =
\mathbf{B}_{eq}, but these fixed value constraints appear so often that they
merit a dedicated function: min_quad_with_fixed.
Linear equality constraints¶
We saw above that min_quad_with_fixed in libigl provides a compact way to
solve general quadratic programs. Let’s consider another example, this time
with active linear equality constraints. Specifically let’s solve the
bi-Laplace equation or equivalently minimize the Laplace energy:
subject to fixed value constraints and a linear equality constraint:
z_{a} = 1, z_{b} = -1 and z_{c} = z_{d}.
Notice that we can rewrite the last constraint in the familiar form from above:
z_{c} - z_{d} = 0.
Now we can assembly Aeq as a 1 \times n sparse matrix with a coefficient
1 in the column corresponding to vertex c and a -1 at d. The right-hand
side Beq is simply zero.
Internally, min_quad_with_fixed solves using the Lagrange Multiplier
method. This method adds additional variables for each linear constraint (in
general a m \times 1 vector of variables \lambda) and then solves the
saddle problem:
This can be rewritten in a more familiar form by stacking \mathbf{z} and \lambda into one (m+n) \times 1 vector of unknowns:
Differentiating with respect to \left( \mathbf{z}^T \lambda^T \right) reveals
a linear system and we can solve for \mathbf{z} and \lambda. The only
difference from the straight quadratic minimization system, is that this
saddle problem system will not be positive definite. Thus, we must use a
different factorization technique (LDLT rather than LLT): libigl’s
min_quad_with_fixed automatically chooses the correct solver in
the presence of linear equality constraints.
The following example first solves with just fixed value constraints (left: 1 and -1 on the left hand and foot respectively), then solves with an additional linear equality constraint (right: points on right hand and foot constrained to be equal).
v, f = igl.read_triangle_mesh(os.path.join(root_folder, "data", "cheburashka.off")) ## Two fixed points: Left hand, left foot should have values 1 and -1 b = np.array([4331, 5957]) bc = np.array([1., -1.]) B = np.zeros((v.shape[0], 1)) ## Construct Laplacian and mass matrix L = igl.cotmatrix(v, f) M = igl.massmatrix(v, f, igl.MASSMATRIX_TYPE_VORONOI) Minv = sp.sparse.diags(1 / M.diagonal()) ## Bi-Laplacian Q = L @ (Minv @ L) ## Solve with only equality constraints Aeq = sp.sparse.csc_matrix((0, 0)) Beq = np.array([]) _, z1 = igl.min_quad_with_fixed(Q, B, b, bc, Aeq, Beq, True) ## Solve with equality and linear constraints Aeq = sp.sparse.csc_matrix((1, v.shape[0])) Aeq[0,6074] = 1 Aeq[0, 6523] = -1 Beq = np.array([0.]) _, z2 = igl.min_quad_with_fixed(Q, B, b, bc, Aeq, Beq, True) ## Normalize colors to same range min_z = min(np.min(z1), np.min(z2)) max_z = max(np.max(z1), np.max(z2)) z = [(z1 - min_z) / (max_z - min_z), (z2 - min_z) / (max_z - min_z)] ## Plot the functions p = plot(v, f, z1, shading={"wireframe":False}, return_plot=True) @interact(function=[('z0', 0), ('z1', 1)]) def sf(function): p.update_object(colors=z[function])
/Users/teseo/conda/envs/base2/lib/python3.6/site-packages/ipykernel_launcher.py:23: SparseEfficiencyWarning: Changing the sparsity structure of a csc_matrix is expensive. lil_matrix is more efficient.
/Users/teseo/conda/envs/base2/lib/python3.6/site-packages/ipykernel_launcher.py:24: SparseEfficiencyWarning: Changing the sparsity structure of a csc_matrix is expensive. lil_matrix is more efficient.
interactive(children=(Dropdown(description='function', options=(('z0', 0), ('z1', 1)), value=0), Output()), _d…
Quadratic programming¶
We can generalize the quadratic optimization in the previous section even more by allowing inequality constraints. Specifically box constraints (lower and upper bounds):
\mathbf{l} \le \mathbf{z} \le \mathbf{u},
where \mathbf{l},\mathbf{u} are n \times 1 vectors of lower and upper bounds and general linear inequality constraints:
\mathbf{A}_{ieq} \mathbf{z} \le \mathbf{B}_{ieq},
where \mathbf{A}_{ieq} is a k \times n matrix of linear coefficients and \mathbf{B}_{ieq} is a k \times 1 matrix of constraint right-hand sides.
Again, we are overly general as the box constraints could be written as rows of the linear inequality constraints, but bounds appear frequently enough to merit a dedicated api.
Libigl implements its own active set routine for solving quadratric programs (QPs). This algorithm works by iteratively “activating” violated inequality constraints by enforcing them as equalities and “deactivating” constraints which are no longer needed.
After deciding which constraints are active at each iteration, the problem reduces to a quadratic minimization subject to linear equality constraints, and the method from the previous section is invoked. This is repeated until convergence.
Currently the implementation is efficient for box constraints and sparse non-overlapping linear inequality constraints.
Unlike alternative interior-point methods, the active set method benefits from a warm-start (initial guess for the solution vector \mathbf{z}).
The following example uses an active set solver to optimize discrete biharmonic kernels (Rustamov, 2011) at multiple scales:
#TODO: Check why results differ, add interactivity v, f, _ = igl.read_off(os.path.join(root_folder, "data", "cheburashka.off")) # One fixed point on belly b = np.array([[2556]]) bc = np.array([[1.0]]) # Construct Laplacian and mass matrix l = igl.cotmatrix(v, f) m = igl.massmatrix(v, f, igl.MASSMATRIX_TYPE_VORONOI) minv = sp.sparse.diags(1 / m.diagonal()) # Bi-Laplacian q = l @ (minv @ l) # Zero linear term bz = np.zeros((v.shape[0], 1)) # Lower and upper bound lx = np.zeros((v.shape[0], 1)) ux = np.ones((v.shape[0], 1)) # Equality constraint constrains solution to sum to 1 beq = np.array([[0.08]]) aeq = sp.sparse.csc_matrix(m.diagonal()) # Empty inequality constraints aieq = sp.sparse.csc_matrix((0, 0)) bieq = np.array([]) z = igl.active_set(q, bz, b, bc, aeq, beq, aieq, bieq, lx, ux, max_iter=8) plot(v, f, z[1])
Eigen Decomposition¶
Libigl has rudimentary support for extracting eigen pairs of a generalized eigen value problem:
Ax = \lambda B x
where A is a sparse symmetric matrix and B is a sparse positive definite matrix. Most commonly in geometry processing, we let A=L the cotangent Laplacian and B=M the per-vertex mass matrix (Vallet, 2008). Typically applications will make use of the low frequency eigen modes. Analogous to the Fourier decomposition, a function f on a surface can be represented via its spectral decomposition of the eigen modes of the Laplace-Beltrami:
f = \sum\limits_{i=1}^\infty a_i \phi_i
where each \phi_i is an eigen function satisfying: \Delta \phi_i = \lambda_i \phi_i and a_i are scalar coefficients. For a discrete triangle mesh, a completely analogous decomposition exists, albeit with finite sum:
\mathbf{f} = \sum\limits_{i=1}^n a_i \phi_i
where now a column vector of values at vertices \mathbf{f} \in \mathcal{R}^n specifies a piecewise linear function and \phi_i \in \mathcal{R}^n is an eigen vector satisfying:
\mathbf{L} \phi_i = \lambda_i \mathbf{M} \phi_i.
Note that Vallet & Levy (Vallet, 2008) propose solving a symmetrized standard eigen problem \mathbf{M}^{-1/2}\mathbf{L}\mathbf{M}^{-1/2} \phi_i = \lambda_i \phi_i. Libigl implements a generalized eigen problem solver so this unnecessary symmetrization can be avoided.
Often the sum above is truncated to the first k eigen vectors. If the low frequency modes are chosen, i.e. those corresponding to small \lambda_i values, then this truncation effectively regularizes \mathbf{f} to smooth, slowly changing functions over the mesh (Hildebrandt, 2011). Modal analysis and model subspaces have been used frequently in real-time deformation (Barbic, 2005).
In the following example, the first k eigen vectors of the discrete Laplace-Beltrami operator are computed and displayed in pseudocolors atop the beetle. Low frequency eigen vectors of the discrete Laplace-Beltrami operator vary smoothly and slowly over the model. At first, calculate the Laplace-Betrami operator and solve the generalized Eigen problem with scipy/arpack. Then, rescale the Eigen vectors and visualize them.
v, f = igl.read_triangle_mesh(os.path.join(root_folder, "data", "beetle.off")) l = -igl.cotmatrix(v, f) m = igl.massmatrix(v, f, igl.MASSMATRIX_TYPE_VORONOI) k = 10 d, u = sp.sparse.linalg.eigsh(l, k, m, sigma=0, which="LM") u = (u - np.min(u)) / (np.max(u) - np.min(u)) bbd = 0.5 * np.linalg.norm(np.max(v, axis=0) - np.min(v, axis=0)) p = plot(v, f, bbd * u[:, 0], shading={"wireframe":False, "flat": False}, return_plot=True) @interact(ev=[("EV %i"%i, i) for i in range(k)]) def sf(ev): p.update_object(colors=u[:, ev])
interactive(children=(Dropdown(description='ev', options=(('EV 0', 0), ('EV 1', 1), ('EV 2', 2), ('EV 3', 3), …
Chapter 3: Shape deformation¶
Modern mesh-based shape deformation methods satisfy user deformation constraints at handles (selected vertices or regions on the mesh) and propagate these handle deformations to the rest of the shape smoothly and without removing or distorting details. Libigl provides implementations of a variety of state-of-the-art deformation techniques, ranging from quadratic mesh-based energy minimizers, to skinning methods, to non-linear elasticity-inspired techniques.
Biharmonic deformation¶
The period of research between 2000 and 2010 produced a collection of techniques that cast the problem of handle-based shape deformation as a quadratic energy minimization problem or equivalently the solution to a linear partial differential equation.
There are many flavors of these techniques, but a prototypical subset are those that consider solutions to the bi-Laplace equation, that is a biharmonic function (Botsch, 2004). This fourth-order PDE provides sufficient flexibility in boundary conditions to ensure C^1 continuity at handle constraints in the limit under refinement (Jacobson, 2010).
Biharmonic surfaces¶
Let us first begin our discussion of biharmonic deformation, by considering biharmonic surfaces. We will casually define biharmonic surfaces as surface whose position functions are biharmonic with respect to some initial parameterization:
\Delta^2 \mathbf{x}' = 0
and subject to some handle constraints, conceptualized as “boundary conditions”:
\mathbf{x}'_{b} = \mathbf{x}_{bc}.
where \mathbf{x}' is the unknown 3D position of a point on the surface. So we are asking that the bi-Laplacian of each of spatial coordinate function to be zero.
In libigl, one can solve a biharmonic problem with harmonic_weights
and setting k=2 (bi-harmonic).
This produces a smooth surface that interpolates the handle constraints, but all original details on the surface will be smoothed away. Most obviously, if the original surface is not already biharmonic, then giving all handles the identity deformation (keeping them at their rest positions) will not reproduce the original surface. Rather, the result will be the biharmonic surface that does interpolate those handle positions.
Thus, we may conclude that this is not an intuitive technique for shape deformation.
Biharmonic deformation fields¶
Now we know that one useful property for a deformation technique is “rest pose reproduction”: applying no deformation to the handles should apply no deformation to the shape.
To guarantee this by construction we can work with deformation fields (ie. displacements) \mathbf{d} rather than directly with positions \mathbf{x}. Then the deformed positions can be recovered as
\mathbf{x}' = \mathbf{x}+\mathbf{d}.
A smooth deformation field \mathbf{d} which interpolates the deformation fields of the handle constraints will impose a smooth deformed shape \mathbf{x}'. Naturally, we consider biharmonic deformation fields:
\Delta^2 \mathbf{d} = 0
subject to the same handle constraints, but rewritten in terms of their implied deformation field at the boundary (handles).
\mathbf{d}_b = \mathbf{x}_{bc} - \mathbf{x}_b.
Again we can use harmonic_weights with k=2, but this time solve for the
deformation field and then recover the deformed positions:
v, f = igl.read_triangle_mesh(os.path.join(root_folder, "data", "decimated-max.obj")) v[:,[0, 2]] = v[:,[2, 0]] # Swap X and Z axes u = v.copy() s = igl.read_dmat(os.path.join(root_folder, "data", "decimated-max-selection.dmat")) b = np.array([[t[0] for t in [(i, s[i]) for i in range(0, v.shape[0])] if t[1] >= 0]]).T ## Boundary conditions directly on deformed positions u_bc = np.zeros((b.shape[0], v.shape[1])) v_bc = np.zeros((b.shape[0], v.shape[1])) for bi in range(b.shape[0]): v_bc[bi] = v[b[bi]] if s[b[bi]] == 0: # Don't move handle 0 u_bc[bi] = v[b[bi]] elif s[b[bi]] == 1: # Move handle 1 down u_bc[bi] = v[b[bi]] + np.array([[0, -50, 0]]) else: # Move other handles forward u_bc[bi] = v[b[bi]] + np.array([[-25, 0, 0]]) p = plot(v, f, s, shading={"wireframe": False, "colormap": "tab10"}, return_plot=True) @interact(deformation_field=True, step=(0.0, 2.0)) def update(deformation_field, step=0.0): # Determine boundary conditions u_bc_anim = v_bc + step * (u_bc - v_bc) if deformation_field: d_bc = u_bc_anim - v_bc d = igl.harmonic_weights(v, f, b, d_bc, 2) u = v + d else: u = igl.harmonic_weights(v, f, b, u_bc_anim, 2) p.update_object(vertices=u)
interactive(children=(Checkbox(value=True, description='deformation_field'), FloatSlider(value=0.0, descriptio…
Relationship to “differential coordinates” and Laplacian surface editing¶
Biharmonic functions (whether positions or displacements) are solutions to the bi-Laplace equation, but also minimizers of the “Laplacian energy”. For example, for displacements \mathbf{d}, the energy reads
\int\limits_S \|\Delta \mathbf{d}\|^2 dA,
where we define \Delta \mathbf{d} to simply apply the Laplacian coordinate-wise.
By linearity of the Laplace(-Beltrami) operator we can reexpress this energy in terms of the original positions \mathbf{x} and the unknown positions \mathbf{x}' = \mathbf{x} - \mathbf{d}:
\int\limits_S \|\Delta (\mathbf{x}' - \mathbf{x})\|^2 dA = \int\limits_S \|\Delta \mathbf{x}' - \Delta \mathbf{x})\|^2 dA.
In the early work of Sorkine et al., the quantities \Delta \mathbf{x}' and \Delta \mathbf{x} were dubbed “differential coordinates” (Sorkine, 2004). Their deformations (without linearized rotations) is thus equivalent to biharmonic deformation fields.
Polyharmonic deformation¶
We can generalize biharmonic deformation by considering different powers of the Laplacian, resulting in a series of PDEs of the form:
\Delta^k \mathbf{d} = 0.
with k\in{1,2,3,\dots}. The choice of k determines the level of continuity at the handles. In particular, k=1 implies C^0 at the boundary, k=2 implies C^1, k=3 implies C^2 and in general k implies C^{k-1}.
The following example deforms a flat domain (left) into a bump as a solution to various k-harmonic PDEs.
v, f = igl.read_triangle_mesh(os.path.join(root_folder, "data", "bump-domain.obj")) u = v.copy() # Find boundary vertices outside annulus vrn = np.linalg.norm(v, axis = 1) is_outer = [vrn[i] - 1.00 > -1e-15 for i in range(v.shape[0])] is_inner = [vrn[i] - 0.15 < 1e-15 for i in range(v.shape[0])] in_b = [is_outer[i] or is_inner[i] for i in range(len(is_outer))] b = np.array([i for i in range(v.shape[0]) if (in_b[i])]).T bc = np.zeros(b.size) for bi in range(b.size): bc[bi] = 0.0 if is_outer[b[bi]] else 1.0 c = np.array(is_outer) p = plot(u, f, c, shading={"wire_width": 0.01, "colormap": "tab10"}, return_plot=True) @interact(z_max=(0.0, 1.0), k=(1, 4)) def update(z_max, k): z = igl.harmonic_weights(v, f, b, bc, int(k)) u[:, 2] = z_max * z p.update_object(vertices=u)
interactive(children=(FloatSlider(value=0.5, description='z_max', max=1.0), IntSlider(value=2, description='k'…
As-rigid-as-possible¶
Skinning and other linear methods for deformation are inherently limited. Difficulties arise especially when large rotations are imposed by the handle constraints.
In the context of energy-minimization approaches, the problem stems from comparing positions (our displacements) in the coordinate frame of the undeformed shape. These quadratic energies are at best invariant to global rotations of the entire shape, but not smoothly varying local rotations. Thus linear techniques will not produce non-trivial bending and twisting.
Furthermore, when considering solid shapes (e.g. discretized with tetrahedral meshes) linear methods struggle to maintain local volume, and they often suffer from shrinking and bulging artifacts.
Non-linear deformation techniques present a solution to these problems. They work by comparing the deformation of a mesh vertex to its rest position rotated to a new coordinate frame which best matches the deformation. The non-linearity stems from the mutual dependence of the deformation and the best-fit rotation. These techniques are often labeled “as-rigid-as-possible” as they penalize the sum of all local deformations’ deviations from rotations.
To arrive at such an energy, let’s consider a simple per-triangle energy:
E_\text{linear}(\mathbf{X}') = \sum\limits_{t \in T} a_t \sum\limits_{\{i,j\} \in t} w_{ij} \left\| \left(\mathbf{x}'_i - \mathbf{x}'_j\right) - \left(\mathbf{x}_i - \mathbf{x}_j\right)\right\|^2
where \mathbf{X}' are the mesh’s unknown deformed vertex positions, t is a triangle in a list of triangles T, a_t is the area of triangle t and \{i,j\} is an edge in triangle t. Thus, this energy measures the norm of change between an edge vector in the original mesh \left(\mathbf{x}_i - \mathbf{x}_j\right) and the unknown mesh \left(\mathbf{x}'_i - \mathbf{x}'_j\right).
This energy is not rotation invariant. If we rotate the mesh by 90 degrees the change in edge vectors not aligned with the axis of rotation will be large, despite the overall deformation being perfectly rigid.
So, the “as-rigid-as-possible” solution is to append auxiliary variables \mathbf{R}_t for each triangle t which are constrained to be rotations. Then the energy is rewritten, this time comparing deformed edge vectors to their rotated rest counterparts:
E_\text{arap}(\mathbf{X}',\{\mathbf{R}_1,\dots,\mathbf{R}_{|T|}\}) = \sum\limits_{t \in T} a_t \sum\limits_{\{i,j\} \in t} w_{ij} \left\| \left(\mathbf{x}'_i - \mathbf{x}'_j\right)- \mathbf{R}_t\left(\mathbf{x}_i - \mathbf{x}_j\right)\right\|^2.
The separation into the primary vertex position variables \mathbf{X}' and the rotations \{\mathbf{R}_1,\dots,\mathbf{R}_{|T|}\} lead to strategy for optimization, too. If the rotations \{\mathbf{R}_1,\dots,\mathbf{R}_{|T|}\} are held fixed then the energy is quadratic in the remaining variables \mathbf{X}' and can be optimized by solving a (sparse) global linear system. Alternatively, if \mathbf{X}' are held fixed then each rotation is the solution to a localized Procrustes problem (found via 3 \times 3 SVD or polar decompostion). These two steps—local and global—each weakly decrease the energy, thus we may safely iterate them until convergence.
The different flavors of “as-rigid-as-possible” depend on the dimension and codimension of the domain and the edge-sets T. The proposed surface manipulation technique by Sorkine and Alexa (Sorkine, 2007), considers T to be the set of sets of edges emanating from each vertex (spokes). Later, Chao et al. derived the relationship between “as-rigid-as-possible” mesh energies and co-rotational elasticity considering 0-codimension elements as edge-sets: triangles in 2D and tetrahedra in 3D (Chao, 2010). They also showed how Sorkine and Alexa’s edge-sets are not a discretization of a continuous energy, proposing instead edge-sets for surfaces containing all edges of elements incident on a vertex (spokes and rims). They show that this amounts to measuring bending, albeit in a discretization-dependent way.
Libigl, supports these common flavors. Selecting one is a matter of setting the energy type before the precompuation phase.
#arap_data.energy = igl::ARAP_ENERGY_TYPE_SPOKES; #arap_data.energy = igl::ARAP_ENERGY_TYPE_SPOKES_AND_RIMS; #arap_data.energy = igl::ARAP_ENERGY_TYPE_ELEMENTS; arap = igl.ARAP(v, f, dimension, b)
igl.min_quad_with_fixed_*, this precomputation phase only depends on the mesh, fixed vertex indices b and the energy parameters. To solve with certain constraints on the positions of vertices in b, we may call:
vn = arap.solve(bc, v)
which uses v as an initial guess and then computes the solution into it.
Libigl’s implementation of as-rigid-as-possible deformation takes advantage of the highly optimized singular value decomposition code from McAdams et al. (McAdams, 2011) which leverages SSE intrinsics.
The following example deforms a surface as if it were made of an elastic material. The concept of local rigidity will be revisited shortly in the context of surface parameterization.
v, f = igl.read_triangle_mesh(os.path.join(root_folder, "data", "decimated-knight.off")) s = igl.read_dmat(os.path.join(root_folder, "data", "decimated-knight-selection.dmat")) # Vertices in selection b = np.array([[t[0] for t in [(i, s[i]) for i in range(0, v.shape[0])] if t[1] >= 0]]).T # Centroid mid = 0.5 * (np.max(v, axis=0) + np.min(v, axis=0)) # Precomputation arap = igl.ARAP(v, f, 3, b) # Set color based on selection c = np.ones_like(f) * np.array([1.0, 228/255, 58/255]) for fi in range(0, f.shape[0]): if s[f[fi, 0]] >= 0 and s[f[fi, 1]] >= 0 and s[f[fi, 2]] >= 0: c[fi] = np.array([80/255, 64/255, 1.0]) # Plot the mesh with pseudocolors p = plot(v, f, c, return_plot=True) @interact(t=(0.0, 10.0)) def update(t=1.0): bc = np.zeros((b.size, v.shape[1])) for i in range(0, b.size): bc[i] = v[b[i]] if s[b[i]] == 0: r = mid[0] * 0.25 bc[i, 0] += r * np.sin(0.5 * t * 2 * np.pi) bc[i, 1] = bc[i, 1] - r + r * np.cos(np.pi + 0.5 * t * 2 * np.pi) elif s[b[i]] == 1: r = mid[1] * 0.15 bc[i, 1] = bc[i, 1] + r + r * np.cos(np.pi + 0.15 * t * 2 * np.pi) bc[i, 2] -= r * np.sin(0.15 * t * 2 * np.pi) elif s[b[i]] == 2: r = mid[1] * 0.15 bc[i, 2] = bc[i, 2] + r + r * np.cos(np.pi + 0.35 * t * 2 * np.pi) bc[i, 0] += r * np.sin(0.35 * t * 2 * np.pi) vn = arap.solve(bc, v) p.update_object(vertices=vn)
interactive(children=(FloatSlider(value=1.0, description='t', max=10.0), Output()), _dom_classes=('widget-inte…
Chapter 4: Parametrization¶
In computer graphics, we denote as surface parametrization a map from the surface to \(\mathbf{R}^2\). It is usually encoded by a new set of 2D coordinates for each vertex of the mesh (and possibly also by a new set of faces in one to one correspondence with the faces of the original surface). Note that this definition is the inverse of the classical differential geometry definition.
A parametrization has many applications, ranging from texture mapping to surface remeshing. Many algorithms have been proposed, and they can be broadly divided in four families:
-
Single patch, fixed boundary: these algorithm can parametrize a disk-like part of the surface given fixed 2D positions for its boundary. These algorithms are efficient and simple, but they usually produce high-distortion maps due to the fixed boundary.
-
Single patch, free boundary: these algorithms let the boundary deform freely, greatly reducing the map distortion. Care should be taken to prevent the border to self-intersect.
-
Global parametrization: these algorithms work on meshes with arbitrary genus. They initially cut the mesh in multiple patches that can be separately parametrized. The generated maps are discontinuous on the cuts (often referred as seams).
-
Global seamless parametrization: these are global parametrization algorithm that hides the seams, making the parametrization “continuous”, under specific assumptions that we will discuss later.
Harmonic parametrization¶
Harmonic parametrization (Eck, 2005) is a single patch, fixed boundary parametrization algorithm that computes the 2D coordinates of the flattened mesh as two harmonic functions.
The algorithm is divided in 3 steps:
- Detection of the boundary vertices
- Map the boundary vertices to a circle
- Compute two harmonic functions (one for u and one for the v coordinate). The harmonic functions use the fixed vertices on the circle as boundary constraints.
The algorithm is coded with libigl in the following example. bnd contains the indices of the boundary vertices, bnd_uv their position on the UV plane, and “1” denotes that we want to compute an harmonic function (2 will be for biharmonic, 3 for triharmonic, etc.). Note that each of the three
functions is designed to be reusable in other parametrization algorithms.
The UV coordinates are then used to apply a procedural checkerboard texture to the mesh.
v, f = igl.read_triangle_mesh(os.path.join(root_folder, "data", "camelhead.off")) ## Find the open boundary bnd = igl.boundary_loop(f) ## Map the boundary to a circle, preserving edge proportions bnd_uv = igl.map_vertices_to_circle(v, bnd) ## Harmonic parametrization for the internal vertices uv = igl.harmonic_weights(v, f, bnd, bnd_uv, 1) v_p = np.hstack([uv, np.zeros((uv.shape[0],1))]) p = plot(v, f, uv=uv, shading={"wireframe": False, "flat": False}, return_plot=True) @interact(mode=['3D','2D']) def switch(mode): if mode == "3D": plot(v, f, uv=uv, shading={"wireframe": False, "flat": False}, plot=p) if mode == "2D": plot(v_p, f, uv=uv, shading={"wireframe": True, "flat": False}, plot=p)
interactive(children=(Dropdown(description='mode', options=('3D', '2D'), value='3D'), Output()), _dom_classes=…
Least squares conformal maps¶
Least squares conformal maps parametrization (Levy, 2002) minimizes the conformal (angular) distortion of the parametrization. Differently from harmonic parametrization, it does not need to have a fixed boundary.
LSCM minimizes the following energy:
\[ E_{LSCM}(\mathbf{u},\mathbf{v}) = \int_X \frac{1}{2}| \nabla \mathbf{u}^{\perp} - \nabla \mathbf{v} |^2 dA \]
which can be rewritten in matrix form as (Mullen, 2008):
\[ E_{LSCM}(\mathbf{u},\mathbf{v}) = \frac{1}{2} [\mathbf{u},\mathbf{v}]^t (L_c - 2A) [\mathbf{u},\mathbf{v}] \]
where L_c is the cotangent Laplacian matrix and A is a matrix such that [\mathbf{u},\mathbf{v}]^t A [\mathbf{u},\mathbf{v}] is equal to the vector area of the mesh.
Using libigl, this matrix energy can be written in a few lines of code. The
cotangent matrix can be computed using igl.cotmatrix. Note that we want to apply the Laplacian matrix to the u and v coordinates at the same time, thus we need to extend it taking the left Kronecker product with a 2x2 identity matrix. The area matrix is computed with igl.vector_area_matrix.
The final energy matrix is L_{flat} - 2A. Note that in this case we do not need to fix the boundary. To remove the null space of the energy and make the minimum unique, it is sufficient to fix two arbitrary vertices to two arbitrary positions. The full source code is provided in the following LSCM parametrization example.
v, f = igl.read_triangle_mesh(os.path.join(root_folder, "data", "camelhead.off")) # Fix two points on the boundary b = np.array([2, 1]) bnd = igl.boundary_loop(f) b[0] = bnd[0] b[1] = bnd[int(bnd.size / 2)] bc = np.array([[0.0, 0.0], [1.0, 0.0]]) # LSCM parametrization _, uv = igl.lscm(v, f, b, bc) p = plot(v, f, uv=uv, shading={"wireframe": False, "flat": False}, return_plot=True) @interact(mode=['3D','2D']) def switch(mode): if mode == "3D": plot(v, f, uv=uv, shading={"wireframe": False, "flat": False}, plot=p) if mode == "2D": plot(uv, f, uv=uv, shading={"wireframe": True, "flat": False}, plot=p)
interactive(children=(Dropdown(description='mode', options=('3D', '2D'), value='3D'), Output()), _dom_classes=…
As-rigid-as-possible parametrization¶
As-rigid-as-possible parametrization (Liu, 2008) is a powerful single-patch, non-linear algorithm to compute a parametrization that strives to preserve distances (and thus angles). The idea is very similar to ARAP surface deformation: each triangle is mapped to the plane trying to preserve its original shape, up to a rigid rotation.
The algorithm can be implemented reusing the functions discussed in the
deformation chapter: igl.ARAP and arap.solve. The only
difference is that the optimization has to be done in 2D instead of 3D and that
we need to compute a starting point. While for 3D deformation the optimization
is bootstrapped with the original mesh, this is not the case for ARAP
parametrization since the starting point must be a 2D mesh.
In the following example, we initialize the optimization with harmonic parametrization. Similarly to LSCM, the boundary is free to deform to minimize the distortion.
v, f = igl.read_triangle_mesh(os.path.join(root_folder, "data", "camelhead.off")) ## Find the open boundary bnd = igl.boundary_loop(f) ## Map the boundary to a circle, preserving edge proportions bnd_uv = igl.map_vertices_to_circle(v, bnd) ## Harmonic parametrization for the internal vertices uv = igl.harmonic_weights(v, f, bnd, bnd_uv, 1) arap = igl.ARAP(v, f, 2, np.zeros((0))) uva = arap.solve(np.zeros((0, 0)), uv) p = plot(v, f, uv=uva, shading={"wireframe": False, "flat": False}, return_plot=True) @interact(mode=['3D','2D']) def switch(mode): if mode == "3D": plot(v, f, uv=uva, shading={"wireframe": False, "flat": False}, plot=p) if mode == "2D": plot(uva, f, uv=uva, shading={"wireframe": True, "flat": False}, plot=p)
interactive(children=(Dropdown(description='mode', options=('3D', '2D'), value='3D'), Output()), _dom_classes=…
N-rotationally symmetric tangent fields¶
The design of tangent fields is a basic tool used to design guidance fields for uniform quadrilateral and hexahedral remeshing. Libigl contains an implementation of all the state-of-the-art algorithms to design N-RoSy fields and their generalizations.
In libigl, tangent unit-length vector fields are piece-wise constant on the faces of a triangle mesh, and they are described by one or more vectors per-face. The function
output_field, output_singularities = igl.nrosy(v, f, b, bc, b_soft, b_soft_weight, bc_soft, n, 0.5)
creates a smooth unit-length vector field (n=1) starting from a sparse set of constrained faces, whose indices are listed in b and their constrained value is specified in bc. The functions supports soft_constraints (b_soft, b_soft_weight, bc_soft), and returns the interpolated field for each face of the triangle mesh (output_field), plus the singularities of the field (output_singularities).
The singularities are vertices where the field vanishes (highlighted in red in the figure above). igl.nrosy can also generate N-RoSy fields (Levy, 2008), which are a generalization of vector fields where in every face the vector is defined up to a constant rotation of 2\pi / N. As can be observed in the following figure, the singularities of the fields generated with different N are of different types and they appear in different positions.
We demonstrate how to call and plot N-RoSy fields in the following example, where the degree of the field can be changed. igl.nrosy implements the algorithm proposed in (Bommes, 2009). N-RoSy fields can also be interpolated with many other algorithms, see the library libdirectional for a reference implementation of the most popular ones. For a complete categorization of fields used in various applications see (Vaxman, 2016)
# # Converts a representative vector per face in the full set of vectors that describe an N-RoSy field # def representative_to_nrosy(v, f, r, n): # b1, b2, b3 = igl.local_basis(v, f) # ym = np.zeros((f.shape[0] * n, 3)) # for i in range(f.shape[0]): # x = r[i] * b1[i].T # y = r[i] * b2[i].T # angle = np.arctan2(y[0], x[0]) # for j in range(0, n): # anglej = angle + 2 * np.pi * j / float(n) # xj = np.cos(anglej) # yj = np.sin(anglej) # ym[i * n + j] = xj * b1[i] + yj * b2[i] # return ym # # Load a mesh in OFF format and plot it # v, f = igl.read_triangle_mesh(os.path.join(root_folder, "data", "bumpy.off")) # #print(points_n) # # Constrained faces id # b = np.array([[0]]) # # Highlight in red the constrained faces # c = np.ones((f.shape[0], 3)) # for i in range(b.size): # c[b[i]] = np.array([1, 0, 0]) # p = plot(v, f, c) # pn_id = pp_id = e_id = None # # Constrained faces representative vector # bcv = np.array([[1.0, 1.0, 1.0]]) # avg = igl.avg_edge_length(v, f) # bc = igl.barycenter(v, f) # # Plots the mesh with an N-RoSy field and its singularities on top # # The constrained faces (b) are colored in red. # @interact(n=(1, 10)) # def plot_mesh_nrosy(n=1): # global pn_id, pp_id, e_id, bcv, avg, b, bc # r, s = igl.nrosy(v, f, b, bcv, np.array([[]], dtype=np.int64), np.array([[]]), np.array([[]]), n, 0.5) # # Expand the representative vectors in the full vector set and plot them as lines # y = representative_to_nrosy(v, f, r, n) # be = np.zeros((bc.shape[0] * n, 3)) # for i in range(bc.shape[0]): # for j in range(n): # be[i * n + j] = bc[i] # if e_id: # p.remove_object(e_id) # e_id = p.add_lines(be, be + y * (avg / 2)) # # Plot the singularities as colored dots (red for negative, blue for positive) # points_n = [] # points_p = [] # for i in range(0, s.size): # if s[i] < -0.001: # points_n.append(v[i]) # elif s[i] > 0.001: # points_p.append(v[i]) # if pp_id and pn_id: # p.remove_object(pn_id) # p.remove_object(pp_id) # if len(points_n) > 0: # pn_id = p.add_points(np.array(points_n), c="red", shading={"point_size": 2.0}) # if len(points_p) > 0: # pp_id = p.add_points(np.array(points_p), c="blue", shading={"point_size": 2.0})
Planarization¶
A quad mesh can be transformed in a planar quad mesh with Shape-Up (Bouaziz, 2012), a local/global approach that uses the global step to enforce surface continuity and the local step to enforce planarity.
The following example planarizes a quad mesh until it satisfies a user-given planarity threshold. The colors represent the planarity of the quads.
Load a quad mesh generated by a conjugate field¶
vqc, fqc, _ = igl.read_off(os.path.join(root_folder, “data”, “inspired_mesh_quads_Conjugate.off”))
Convert it to a triangle mesh¶
fqc_tri = np.zeros((fqc.shape[0] * 2, 3), dtype=”int64”) fqc_tri[:fqc.shape[0]] = fqc[:, :3] fqc_tri[fqc.shape[0]:, 0] = fqc[:, 2] fqc_tri[fqc.shape[0]:, 1] = fqc[:, 3] fqc_tri[fqc.shape[0]:, 2] = fqc[:, 0]
Planarize it¶
vqc_p = igl.planarize_quad_mesh(vqc, fqc, 100, 0.005)
Calculate a color to each quad that corresponds to its planarity¶
planarity = igl.quad_planarity(vqc, fqc) planarity_p = igl.quad_planarity(vqc_p, fqc)
c = np.concatenate([planarity, planarity]) c_p = np.concatenate([planarity_p, planarity_p])
p = plot(vqc, fqc_tri, c, shading={“normalize”:[min(np.min©, np.min(c_p)), max(np.max©, np.max(c_p))]})
@interact(mode=[‘Curved’,’Planar’]) def switch(mode): if mode == “Curved”: p.update_object(colors=c) if mode == “Planar”: p.update_object(vertices=vqc_p, colors=c_p)
Chapter 5: External libraries¶
An additional positive side effect of using matrices as basic types is that it is easy to exchange data between libigl and other software and libraries.
Baking ambient occlusion¶
Ambient occlusion is a rendering technique used to calculate the exposure of each point in a surface to ambient lighting. It is usually encoded as a scalar (normalized between 0 and 1) associated with the vertice of a mesh.
Formally, ambient occlusion is defined as:
\[ A_p = \frac{1}{\pi} \int_\omega V_{p,\omega}(n \cdot \omega) d\omega \]
where V_{p,\omega} is the visibility function at p, defined to be zero if p is occluded in the direction \omega and one otherwise, and d\omega is the infinitesimal solid angle step of the integration variable \omega.
The integral is usually approximated by casting rays in random directions around each vertex. This approximation can be computed using the function:
ao = igl.ambient_occlusion(v, f, v_samples, n_samples, 500)
that given a scene described in v and f, computes the ambient occlusion of
the points in v_samples whose associated normals are n_samples. The
number of casted rays can be controlled (usually at least 300-500 rays are
required to get a smooth result) and the result is returned in ao, as a
single scalar for each sample.
Ambient occlusion can be used to darken the surface colors, as shown in the following example:
v, f = igl.read_triangle_mesh(os.path.join(root_folder, "data", "fertility.off")) n = igl.per_vertex_normals(v, f) # Compute ambient occlusion factor using embree ao = igl.ambient_occlusion(v, f, v, n, 50) ao = 1.0 - ao plot(v, f, ao, shading={"colormap": "gist_gray"})
Chapter 6: Miscellaneous¶
Libigl contains a wide variety of geometry processing tools and functions for dealing with meshes and the linear algebra related to them: far too many to discuss in this introductory tutorial. We’ve pulled out a couple of the interesting functions in this chapter to highlight.
Mesh Statistics¶
Libigl contains various mesh statistics, including face angles, face areas and the detection of singular vertices, which are vertices with more or less than 6 neighbours in triangulations or 4 in quadrangulations.
The example computes these quantities and does a basic statistic analysis that allows to estimate the isometry and regularity of a mesh:
v, f = igl.read_triangle_mesh(os.path.join(root_folder, "data", "horse_quad.obj")) ## Count the number of irregular vertices, the border is ignored irregular = igl.is_irregular_vertex(v, f) v_count = v.shape[0] irregular_v_count = np.sum(irregular) irregular_ratio = irregular_v_count / v_count print("Irregular vertices: \n%d/%d (%.2f%%)\n"%(irregular_v_count, v_count, irregular_ratio * 100)) ## Compute areas, min, max and standard deviation area = igl.doublearea(v, f) / 2.0 area_avg = np.mean(area) area_min = np.min(area) / area_avg area_max = np.max(area) / area_avg area_ns = (area - area_avg) / area_avg area_sigma = np.sqrt(np.mean(np.square(area_ns))) print("Areas (Min/Max)/Avg_Area Sigma: \n%.2f/%.2f (%.2f)\n"%(area_min, area_max, area_sigma)) ## Compute per face angles, min, max and standard deviation angles = igl.internal_angles(v, f) angles = 360.0 * (angles / (2 * np.pi)) angle_avg = np.mean(angles) angle_min = np.min(angles) angle_max = np.max(angles) angle_ns = angles - angle_avg angle_sigma = np.sqrt(np.mean(np.square(angle_ns))) print("Angles in degrees (Min/Max) Sigma: \n%.2f/%.2f (%.2f)\n"%(angle_min, angle_max, angle_sigma))
Irregular vertices: 659/2400 (27.46%) Areas (Min/Max)/Avg_Area Sigma: 0.01/6.26 (0.88) Angles in degrees (Min/Max) Sigma: 2.36/171.79 (25.02)
The first row contains the number and percentage of irregular vertices, which is particularly important for quadrilateral meshes when they are used to define subdivision surfaces: every singular point will result in a point of the surface that is only C^1.
The second row reports the area of the minimal element, maximal element and the standard deviation. These numbers are normalized by the mean area, so in the example above 5.33 max area means that the biggest face is 5 times larger than the average face. An ideal isotropic mesh would have both min and max area close to 1.
The third row measures the face angles, which should be close to 60 degrees (90 for quads) in a perfectly regular triangulation. For FEM purposes, the closer the angles are to 60 degrees the more stable will the optimization be. In this case, it is clear that the mesh is of bad quality and it will probably result in artifacts if used for solving PDEs.
Subdivision surfaces¶
Given a coarse mesh (aka cage) with vertices V and faces F, one can createa
higher-resolution mesh with more vertices and faces by subdividing every
face. That is, each coarse triangle in the input is replaced by many smaller
triangles. Libigl has three different methods for subdividing a triangle mesh.
An “in plane” subdivision method will not change the point set or carrier surface of the mesh. New vertices are added on the planes of existing triangles and vertices surviving from the original mesh are not moved.
By adding new faces, a subdivision algorithm changes the combinatorics of the mesh. The change in combinatorics and the formula for positioning the high-resolution vertices is called the “subdivision rule”.
For example, in the in plane subdivision method of igl.upsample, vertices
are added at the midpoint of every edge: v_{ab} = \frac{1}{2}(v_a + v_b) and
each triangle (i_a,i_b,i_c) is replaced with four triangles:
(i_a,i_{ab},i_{ca}), (i_b,i_{bc},i_{ab}), (i_{ab},i_{bc},i_{ca}), and
(i_{bc},i_{c},i_{ca}). This process may be applied recursively, resulting in
a finer and finer mesh.
The subdivision method of igl.loop is not in plane. The vertices of the
refined mesh are moved to weight combinations of their neighbors: the mesh is
smoothed as it is refined (Loop, 1987). This and other smooth subdivision
methods can be understood as generalizations of spline curves to surfaces. In
particular the Loop subdivision method will converge to a C^1 surface as we
consider the limit of recursive applications of subdivision. Away from
“irregular” or “extraordinary” vertices (vertices of the original cage with
valence not equal to 6), the surface is C^2. The combinatorics (connectivity
and number of faces) of igl.loop and igl.upsample are identical: the only
difference is that the vertices have been smoothed in igl.loop.
Finally, libigl also implements a form of in plane “false barycentric
subdivision” in igl.false_barycentric_subdivision. This method simply adds
the barycenter of every triangle as a new vertex v_{abc} and replaces each
triangle with three triangles (i_a,i_b,i_{abc}), (i_b,i_c,i_{abc}), and
(i_c,i_a,i_{abc}). In contrast to igl.upsample, this method will create
triangles with smaller and smaller internal angles and new vertices will sample
the carrier surfaces with extreme bias.
ov, of = igl.read_triangle_mesh(os.path.join(root_folder, "data", "decimated-knight.off")) uv, uf = igl.upsample(ov, of) lv, lf = igl.loop(ov, of) p = plot(ov, of, shading={"wireframe": True}) @interact(mode=['Coarse','Upsample', 'Loop']) def switch(mode): if mode == "Coarse": plot(ov, of, shading={"wireframe": True}, plot=p) if mode == "Upsample": plot(uv, uf, shading={"wireframe": True}, plot=p) #p.update_object(vertices=uv, faces=uf) if mode == "Loop": plot(lv, lf, shading={"wireframe": True}, plot=p)
interactive(children=(Dropdown(description='mode', options=('Coarse', 'Upsample', 'Loop'), value='Coarse'), Ou…
Data smoothing¶
A noisy function f defined on a surface \Omega can be smoothed using an energy minimization that balances a smoothing term E_S with a quadratic fitting term:
u = \operatorname{argmin}_u \alpha E_S(u) + (1-\alpha)\int_\Omega ||u-f||^2 dx
The parameter \alpha determines how aggressively the function is smoothed.
A classical choice for the smoothness energy is the Laplacian energy of the function with zero Neumann boundary conditions, which is a form of the biharmonic energy. It is constructed using the cotangent Laplacian L and
the mass matrix M: QL = L'*(M\L). Because of the implicit zero Neumann boundary conditions however, the function behavior is significantly warped at the boundary if f does not have zero normal gradient at the boundary.
In (Stein, 2017) it is suggested to use the Biharmonic energy with natural
Hessian boundary conditions instead, which corresponds to the hessian energy with the matrix QH = H'*(M2\H), where H is a finite element Hessian and M2 is a stacked mass matrix. The matrices H and QH are implemented in
libigl as igl.hessian and igl.hessian_energy respectively.
In the following example the differences between the Laplacian energy with zero Neumann boundary conditions and the Hessian energy can be clearly seen: whereas the zero Neumann boundary condition in the third image bias the isolines of the function to be perpendicular to the boundary, the Hessian energy gives an unbiased result.
The following example shows a function on the beetle mesh, the function with added noise, the result of smoothing with the Laplacian energy and zero Neumann boundary conditions, and the result of smoothing with the Hessian energy.
v, f = igl.read_triangle_mesh(os.path.join(root_folder, "data", "beetle.off")) e = igl.edges(f) # Constructing an exact function to smooth z_exact = v[0:, 2] + 0.5 * v[0:, 1] + v[0:, 1] * v[0:, 1] + v[0:, 2] * v[0:, 2] * v[0:, 2] # Make the exact function noisy s = 0.2 * (np.max(z_exact) - np.min(z_exact)) np.random.seed(5) z_noisy = z_exact + s * np.random.rand(*z_exact.shape) # Constructing the squared Laplacian and squared Hessian energy l = igl.cotmatrix(v, f) m = igl.massmatrix(v, f, igl.MASSMATRIX_TYPE_BARYCENTRIC) m_inv_l = sp.sparse.linalg.spsolve(m, l) ql = l.T @ m_inv_l qh = igl.hessian_energy(v, f) # Solve to find Laplacian-smoothed and Hessian-smoothed solutions al = 8e-4; zl = sp.sparse.linalg.spsolve(al * ql + (1 - al) * m, al * m.dot(z_noisy)) ah = 5e-6; zh = sp.sparse.linalg.spsolve(ah * qh + (1 - ah) * m, ah * m.dot(z_noisy)) # Calculate isolines ilx_v, ilx_e = igl.isolines(v, f, z_exact, 30) iln_v, iln_e = igl.isolines(v, f, z_noisy, 30) ill_v, ill_e = igl.isolines(v, f, zl, 30) ilh_v, ilh_e = igl.isolines(v, f, zh, 30) p = plot(v, f, z_exact) e_id = p.add_edges(ilx_v, ilx_e) @interact(mode=['Original', 'Noisy', 'Biharmonic smoothing (0-Neumann)', 'Biharmonic smoothing (Natural Hessian)']) def switch(mode): global e_id p.remove_object(e_id) if mode == "Original": p.update_object(colors=z_exact) e_id = p.add_edges(ilx_v, ilx_e) if mode == "Noisy": p.update_object(colors=z_noisy) e_id = p.add_edges(iln_v, iln_e) if mode == "Biharmonic smoothing (0-Neumann)": p.update_object(colors=zl) e_id = p.add_edges(ill_v, ill_e) if mode == "Biharmonic smoothing (Natural Hessian)": p.update_object(colors=zh) e_id = p.add_edges(ilh_v, ilh_e)
Invalid object id. Valid ids are: [0, 1]
Marching Tetrahedra¶
Often 3D data is captured as scalar field defined over space f(\mathbf{x}) : \mathcal{R}^3 \rightarrow \mathcal{R}. Lurking within this field, iso-surfaces of the scalar field are often salient geometric objects. The iso-surface at value v is composed of all points \mathbf{x} in \mathcal{R}^3 such that f(\mathbf{x}) = v. A core problem in geometry processing is to extract an iso-surface as a triangle mesh for further mesh-based processing or visualization. This is referred to as iso-contouring.
“Marching Tetrahedra” (Treece, 1999) is a famous method for iso-contouring tri-linear functions f on a 3D simplicial complex (aka a tet mesh). The core idea of this method is to contour the iso-surface passing through each cell (if it does at all) with a predefined topology (aka connectivity) chosen from a look up tabledepending on the function values at each vertex of the cell. The method iterates (“marches”) over all cells (“tetrahedra”) in the complex and stitches together the final mesh.
In libigl, igl.marching_tets constructs a triangle mesh (v,f) approximating the iso-level set for the value isovalue from an input scalar field s sampled at the vertices of a tet mesh locations (tv, tt):
v, f = igl.marching_tets(tv, tt, s, isovalue)
tv = np.load(os.path.join(root_folder, "data", "marching_cube_tv.npy")) tt = np.load(os.path.join(root_folder, "data", "marching_cube_tt.npy")) s = np.linalg.norm(tv, axis=1) svs = [] sfs = [] for i in np.linspace(0.05, 0.75, 15): sv, sf, _, _ = igl.marching_tets(tv, tt, s, i) svs.append(sv) sfs.append(sf) p = plot(sv, sf, return_plot=True) oid = 0 @interact(t=(0, 14)) def update(t=0): global oid p.remove_object(oid) oid = p.add_mesh(svs[t], sfs[t], update=False)
interactive(children=(IntSlider(value=0, description='t', max=14), Output()), _dom_classes=('widget-interact',…
References¶
-
Alec Jacobson, Algorithms and Interfaces for Real-Time Deformation of 2D and 3D Shapes, 2013. ↩
-
Michael Kazhdan, Jake Solomon, Mirela Ben-Chen, Can Mean-Curvature Flow Be Made Non-Singular, 2012. ↩
-
Mark Meyer, Mathieu Desbrun, Peter Schröder and Alan H. Barr, Discrete Differential-Geometry Operators for Triangulated 2-Manifolds, 2003. ↩
-
Joseph S. B. Mitchell, David M. Mount, Christos H. Papadimitriou. The Discrete Geodesic Problem, 1987 ↩
-
Daniele Panozzo, Enrico Puppo, Luigi Rocca, Efficient Multi-scale Curvature and Crease Estimation, 2010. ↩
-
Andrei Sharf, Thomas Lewiner, Gil Shklarski, Sivan Toledo, and Daniel Cohen-Or. Interactive topology-aware surface reconstruction, 2007. ↩
-
Jernej Barbic and Doug James. Real-Time Subspace Integration for St.Venant-Kirchhoff Deformable Models, 2005. ↩
-
Klaus Hildebrandt, Christian Schulz, Christoph von Tycowicz, and Konrad Polthier. Interactive Surface Modeling using Modal Analysis, 2011. ↩
-
Raid M. Rustamov, Multiscale Biharmonic Kernels, 2011. ↩
-
Bruno Vallet and Bruno Lévy. Spectral Geometry Processing with Manifold Harmonics, 2008. ↩
-
Matrio Botsch and Leif Kobbelt. An Intuitive Framework for Real-Time Freeform Modeling, 2004. ↩
-
Isaac Chao, Ulrich Pinkall, Patrick Sanan, Peter Schröder. A Simple Geometric Model for Elastic Deformations, 2010. ↩
-
Alec Jacobson, Ilya Baran, Jovan Popović, and Olga Sorkine. Bounded Biharmonic Weights for Real-Time Deformation, 2011. ↩
-
Alec Jacobson, Ilya Baran, Ladislav Kavan, Jovan Popović, and Olga Sorkine. Fast Automatic Skinning Transformations, 2012. ↩
-
Alec Jacobson, Elif Tosun, Olga Sorkine, and Denis Zorin. Mixed Finite Elements for Variational Surface Modeling, 2010. ↩
-
Alec Jacobson, Zhigang Deng, Ladislav Kavan, J.P. Lewis. Skinning: Real-Time Shape Deformation, 2014. ↩
-
Ladislav Kavan, Steven Collins, Jiri Zara, and Carol O’Sullivan. Geometric Skinning with Approximate Dual Quaternion Blending, 2008. ↩
-
Alexa McAdams, Andrew Selle, Rasmus Tamstorf, Joseph Teran, Eftychios Sifakis. Computing the Singular Value Decomposition of 3x3 matrices with minimal branching and elementary floating point operations, 2011. ↩
-
Olga Sorkine, Yaron Lipman, Daniel Cohen-Or, Marc Alexa, Christian Rössl and Hans-Peter Seidel. Laplacian Surface Editing, 2004. ↩
-
Olga Sorkine and Marc Alexa. As-rigid-as-possible Surface Modeling, 2007. ↩
-
Yu Wang, Alec Jacobson, Jernej Barbic, Ladislav Kavan. Linear Subspace Design for Real-Time Shape Deformation, 2015 ↩
-
David Bommes, Henrik Zimmer, Leif Kobbelt. Mixed-integer quadrangulation, 2009. ↩
-
Sofien Bouaziz, Mario Deuss, Yuliy Schwartzburg, Thibaut Weise, Mark Pauly Shape-Up: Shaping Discrete Geometry with Projections, 2012 ↩
-
Matthias Eck, Tony DeRose, Tom Duchamp, Hugues Hoppe, Michael Lounsbery, Werner Stuetzle. Multiresolution Analysis of Arbitrary Meshes, 2005. ↩
-
Bruno Lévy, Sylvain Petitjean, Nicolas Ray, Jérome Maillot. Least Squares Conformal Maps, for Automatic Texture Atlas Generation, 2002. ↩
-
Nicolas Ray, Bruno Vallet, Wan Chiu Li, Bruno Lévy. N-Symmetry Direction Field Design, 2008. ↩
-
Ligang Liu, Lei Zhang, Yin Xu, Craig Gotsman, Steven J. Gortler. A Local/Global Approach to Mesh Parameterization, 2008. ↩
-
Patrick Mullen, Yiying Tong, Pierre Alliez, Mathieu Desbrun. Spectral Conformal Parameterization, 2008. ↩
-
Daniele Panozzo, Enrico Puppo, Marco Tarini, Olga Sorkine-Hornung. Frame Fields: Anisotropic and Non-Orthogonal Cross Fields, 2014. ↩
-
Amir Vaxman, Marcel Campen, Olga Diamanti, Daniele Panozzo, David Bommes, Klaus Hildebrandt, Mirela Ben-Chen. Directional Field Synthesis, Design, and Processing, 2016 ↩
-
Christian Schüller, Ladislav Kavan, Daniele Panozzo, Olga Sorkine-Hornung. Locally Injective Mappings, 2013. ↩
-
Qingnan Zhou, Eitan Grinspun, Denis Zorin. Mesh Arrangements for Solid Geometry, 2016 ↩
-
J Andreas Baerentzen and Henrik Aanaes. Signed distance computation using the angle weighted pseudonormal, 2005. ↩
-
Akash Garg, Alec Jacobson, Eitan Grinspun. Computational Design of Reconfigurables, 2016 ↩
-
Hugues Hoppe. Progressive Meshes, 1996 ↩
-
Alec Jacobson, Ladislav Kavan, and Olga Sorkine. Robust Inside-Outside Segmentation using Generalized Winding Numbers, 2013. ↩
-
Charles Loop. Smooth Subdivision Surfaces Based on Triangles, 1987. ↩
-
W.E. Lorensen and Harvey E. Cline. Marching cubes: A high resolution 3d surface construction algorithm, 1987. ↩
-
Michael Rabinovich, Roi Poranne, Daniele Panozzo, Olga Sorkine-Hornung. Scalable Locally Injective Mappings, 2016. ↩
-
William J. Schroeder, William E. Lorensen, and Steve Linthicum. Implicit Modeling of Swept Surfaces and Volumes, 1994. ↩
-
Kenshi Takayama, Alec Jacobson, Ladislav Kavan, Olga Sorkine-Hornung. A Simple Method for Correcting Facet Orientations in Polygon Meshes Based on Ray Casting, 2014. ↩
-
G.M. Treece, R.W. Prager, and A.H.Gee Regularised marching tetrahedra: improved iso-surface extraction, 1999. ↩
-
Keenan Crane, Clarisse Weischedel, and Max Wardetzky. Geodesics in Heat: A New Approach to Computing Distance Based on Heat Flow, 2013. ↩
-
Alexander I. Bobenko and Boris A. Springborn. A discrete Laplace-Beltrami operator for simplicial surfaces, 2005. ↩
-
Zhongshi Jiang, Scott Schaefer, Daniele Panozzo. SCAF: Simplicial Complex Augmentation Framework for Bijective Maps, 2017 ↩